Введение
Данная инструкция поможет перенести готовый проект yt-dlp-web с одного сервера на другой с сохранением виртуального окружения и настройки systemd службы для автоматического запуска.
Что такое Deno и зачем его устанавливать
Deno – современная платформа для запуска JavaScript и TypeScript вне браузера, разработанная автором Node.js. Она обеспечивает безопасное и удобное выполнение скриптов, включая улучшенную поддержку современных возможностей языка и встроенную работу с файлами, сетью и окружением.
Крайние релизы yt-dlp теперь требуют стороннюю среду выполнения JavaScript для корректной работы с YouTube. Это связано с масштабными изменениями на стороне YouTube и обновлениями кода yt-dlp. Встроенный интерпретатор JavaScript внутри yt-dlp с сентября перестал справляться с новыми требованиями.
Подробнее можно узнать в обсуждении разработчиков: https://github.com/yt-dlp/yt-dlp/issues/14404
Разработчики yt-dlp рекомендуют использовать Deno.
Установка Deno
Для установки Deno на Linux выполните следующую команду в терминале:
curl -fsSL https://deno.land/install.sh | sh
cкрипт спросит: Edit shell configs to add deno to the PATH? (Y/n) ставим y далее будет: █Deno was added to the PATH. You may need to restart your shell for it to become available.
Чтобы изменения вступили в силу в текущей сессии терминала, просто закройте его и откройте заново или выполните:
source ~/.bashrc
ИЛИ
source ~/.profile
Проверьте корректность установки и версию Deno командой:
deno --version
Чтобы проверить, что yt-dlp действительно вызывает deno, можно запустить yt-dlp из консоли с нужной ссылкой в режиме отладки или с более подробным выводом, например:
/root/.local/bin/yt-dlp -v <url>
Там может появиться информация об использовании deno или о вызовах JavaScript-окружения.
Шаг 1. Архивирование проекта
Перейдите в корневой каталог с проектом:
cd /root
tar czvf yt-dlp-web.tar.gz yt-dlp-web
Шаг 2. Копирование на новый сервер
Перенесите архив на новый сервер, например с помощью scp:
scp yt-dlp-web.tar.gz user@new-server:/root/
Шаг 3. Распаковка и подготовка окружения
На новом сервере распакуйте архив:
cd /root
tar xzvf yt-dlp-web.tar.gz
Шаг 4. Установка Python и создание виртуального окружения
4.1 Установка Python и pip (если отсутствуют):
apt update && apt install -y python3 python3-venv python3-pip ffmpeg
Для Debian/Linux бинарный файл ffmpeg не имеет расширения, а исполняемый файл называется просто:
ffmpeg
Его можно положить, например, в /usr/local/bin/ и дать права на выполнение:
chmod +x ffmpeg
sudo mv ffmpeg /usr/local/bin/
4.2 Создание виртуального окружения и установка зависимостей:
cd /root/yt-dlp-web
python3 -m venv venv
source venv/bin/activate
pip install -r requirements.txt
Если файла requirements.txt нет, установите основные зависимости вручную:
pip install fastapi uvicorn yt-dlp
Завершите работу с виртуальным окружением:
deactivate
Шаг 5. Проверка путей и прав
- В файле
main.pyпроверьте путь к yt-dlp:
python
YT_DLP_PATH = "/root/.local/bin/yt-dlp"
Отредактируйте путь, если yt-dlp находится в другом месте.
- Создайте папку для загрузок и установите права:
mkdir -p /root/yt-dlp-web/downloads
chmod 755 /root/yt-dlp-web/downloads
- Проверьте права на папку
templatesи другие необходимые директории.
Шаг 6. Настройка systemd службы
Создайте файл службы /etc/systemd/system/myapp.service со следующим содержимым:
[Unit]
Description=FastAPI сервер yt-dlp-web
After=network.target
[Service]
User=root
WorkingDirectory=/root/yt-dlp-web
# Добавляем DENO_DIR и убеждаемся, что путь к бинарнику deno в PATH
Environment="PATH=/root/yt-dlp-web/venv/bin:/root/.deno/bin:/usr/local/sbin:/usr/local/bin:/usr/bin"
Environment="DENO_DIR=/root/.deno"
ExecStart=/root/yt-dlp-web/venv/bin/uvicorn main:app --host 0.0.0.0 --port 8000
Restart=always
RestartSec=5s
StandardOutput=journal
StandardError=journal
[Install]
WantedBy=multi-user.target
Вариант содержимого с TLS сертификатом:
[Unit]
Description=FastAPI сервер yt-dlp-web
After=network.target
[Service]
User=root
WorkingDirectory=/root/yt-dlp-web
Environment="PATH=/root/yt-dlp-web/venv/bin:/root/.deno/bin:/usr/local/sbin:/usr/local/bin:/usr/bin"
ExecStart=/root/yt-dlp-web/venv/bin/uvicorn main:app --host 0.0.0.0 --port 8000 --ssl-certfile /etc/letsencrypt/live/server.tonicman.ru/fullchain.pem --ssl-keyfile /etc/letsencrypt/live/server.tonicman.ru/privkey.pem
Restart=always
RestartSec=5s
StandardOutput=journal
StandardError=journal
[Install]
WantedBy=multi-user.target
Если настроена маскировка (Fallback) для 3X-UI + Nginx:
[Unit]
Description=FastAPI сервер yt-dlp-web
After=network.target
[Service]
User=root
WorkingDirectory=/root/yt-dlp-web
Environment="PATH=/root/yt-dlp-web/venv/bin:/root/.deno/bin:/usr/local/sbin:/usr/local/bin:/usr/bin"
# Убрали SSL и добавили --root-path /ytdl
ExecStart=/root/yt-dlp-web/venv/bin/uvicorn main:app --host 127.0.0.1 --port 8000 --root-path /ytdl
Restart=always
RestartSec=5s
StandardOutput=journal
StandardError=journal
[Install]
WantedBy=multi-user.target
6.1 Перезагрузка systemd, включение и запуск сервиса
systemctl daemon-reload
systemctl enable myapp.service
systemctl start myapp.service
systemctl status myapp.service
Шаг 7. Тестирование
- Откройте в браузере
http://IP_нового_сервера:8000 - Проверьте загрузку главной страницы
- Попробуйте скачать видео
- Убедитесь, что файлы появляются в папке
downloads
Шаг 8. Рекомендации по безопасности и удобству
- Рассмотрите запуск сервиса не от
root, а от отдельного пользователя с нужными правами. - Создайте файл
requirements.txtдля удобства установки зависимостей:
source venv/bin/activate
pip freeze > requirements.txt
deactivate
- Настройте обратный прокси (например nginx) и SSL для безопасного доступа.
- Обновляйте yt-dlp и зависимости для корректной работы.
Итог
- Архивируем проект и копируем на новый сервер
- Устанавливаем окружение и зависимости
- Проверяем пути в
main.pyи systemd юните - Настраиваем и запускаем службу systemd
- Проверяем работу и корректность загрузок
Обновление yt-dlp
Для корректной работы проекта важно иметь актуальную версию yt-dlp, расположенную по пути, который указан в main.py:
YT_DLP_PATH = "/root/.local/bin/yt-dlp"
Проверка текущей версии
Проверь установленную версию командой:
/root/.local/bin/yt-dlp --version
Обновление yt-dlp
/usr/bin/python3 -m pip install --upgrade yt-dlp --user
Флаг --user гарантирует, что обновление установится в локальную папку пользователя root, обновляя файл по нужному пути. После обновления снова проверь версию.
Права на выполнение
Убедись, что yt-dlp имеет права на исполнение:
chmod +x /root/.local/bin/yt-dlp
Важно
Если используешь другой путь к yt-dlp, измени переменную YT_DLP_PATH в main.py на актуальный и обновляй yt-dlp соответственно.
Обновление ffmpeg
Для корректной работы с видео и аудио проект использует ffmpeg. Рекомендуется иметь актуальную версию.
ffmpeg -version
Обновление
Обновить ffmpeg можно стандартными командами:
sudo apt update
sudo apt install ffmpeg
Если нужна самая свежая версия, можно добавить официальный PPA:
sudo add-apt-repository ppa:jonathonf/ffmpeg-4
sudo apt update
sudo apt install ffmpeg
Установка статической версии ffmpeg:
cd /usr/local/bin
sudo wget https://johnvansickle.com/ffmpeg/releases/ffmpeg-release-amd64-static.tar.xz
sudo tar -xvf ffmpeg-release-amd64-static.tar.xz
cd ffmpeg-*-amd64-static
sudo cp ffmpeg ffprobe /usr/local/bin/
sudo chmod +x /usr/local/bin/ffmpeg /usr/local/bin/ffprobe
ffmpeg -version
Или скачать сборки с официального сайта https://ffmpeg.org/download.html.
Код проекта
main.py:
import os
import shlex
import tempfile
import asyncio
import re
import time
import unicodedata
import logging
import difflib
from urllib.parse import quote
from typing import Optional, List
from fastapi import FastAPI, HTTPException, UploadFile, File, Form, Request, Query
from fastapi.middleware.cors import CORSMiddleware
from fastapi.responses import JSONResponse, FileResponse, HTMLResponse, StreamingResponse, PlainTextResponse
from fastapi.templating import Jinja2Templates
logging.basicConfig(level=logging.DEBUG)
logger = logging.getLogger(__name__)
app = FastAPI()
app.add_middleware(
CORSMiddleware,
allow_origins=["*"],
allow_methods=["POST", "GET", "OPTIONS"],
allow_headers=["*"],
)
BASE_DIR = os.path.dirname(os.path.abspath(__file__))
TEMPLATES_DIR = os.path.join(BASE_DIR, "templates")
DOWNLOADS_DIR = os.path.join(BASE_DIR, "downloads")
os.makedirs(DOWNLOADS_DIR, exist_ok=True)
templates = Jinja2Templates(directory=TEMPLATES_DIR)
DEFAULT_OPTIONS = [
"-S",
"res:1080,vcodec:av1 (bv*[height<=1080]+ba) / res:1080,vcodec:vp9 (bv*[height<=1080]+ba) / bestvideo[ext=mp4][height<=1080]+bestaudio[ext=m4a] / best[ext=mp4] / best / bv*[height<=1080]+ba / b",
"--merge-output-format",
"mp4",
]
YT_DLP_PATH = "/root/.local/bin/yt-dlp"
OUTPUT_TEMPLATE = os.path.join(DOWNLOADS_DIR, "%(title)s.%(ext)s")
def clean_vk_url(url: str) -> str:
"""Очищает ссылки VK от лишних параметров плейлиста, сохраняя домен пользователя"""
if "vkvideo.ru" in url or "vk.com/video" in url:
match = re.search(r"video-?\d+_\d+", url)
if match:
video_id = match.group(0)
domain = "vkvideo.ru" if "vkvideo.ru" in url else "vk.com"
return "https://" + domain + "/" + video_id
return url
def sanitize_filename(name: str) -> str:
name = name.replace('\uff5c', '_')
return re.sub(r'[<>:"/\\|?*]', '_', name)
def normalize_filename(name: str) -> str:
name = unicodedata.normalize('NFC', name)
name = name.replace('\uff5c', '|')
name = name.replace('?', '|')
return name
def filter_user_options(options: str) -> List[str]:
if not options.strip():
return []
opts = shlex.split(options)
filtered = []
skip_next = False
for opt in opts:
if skip_next:
skip_next = False
continue
if opt in ("-o", "--output"):
skip_next = True
continue
filtered.append(opt)
return filtered
def is_extract_audio(options: str) -> bool:
opts = shlex.split(options)
return any(opt in ("-x", "--extract-audio") for opt in opts)
async def run_yt_dlp_cmd_async(cmd: List[str]):
proc = await asyncio.create_subprocess_exec(
*cmd,
stdout=asyncio.subprocess.PIPE,
stderr=asyncio.subprocess.PIPE,
)
stdout, stderr = await proc.communicate()
if proc.returncode != 0:
raise Exception(f"yt-dlp error: {stderr.decode()}")
class Result:
def __init__(self, stdout: str):
self.stdout = stdout
return Result(stdout.decode())
async def get_target_filename(url: str, options: str) -> Optional[str]:
url = clean_vk_url(url)
user_opts = filter_user_options(options)
try:
if is_extract_audio(options):
cmd = [YT_DLP_PATH] + user_opts + ["--get-filename", "-o", OUTPUT_TEMPLATE, url]
else:
cmd = [YT_DLP_PATH] + DEFAULT_OPTIONS + user_opts + ["--get-filename", "-o", OUTPUT_TEMPLATE, url]
result = await run_yt_dlp_cmd_async(cmd)
fname = result.stdout.strip().split("\n")[-1]
fname = normalize_filename(fname)
logger.debug(f"Определённое имя файла: {fname}")
filename = sanitize_filename(os.path.basename(fname))
return filename
except Exception:
return None
def build_cmd(url: str, options: str, output_template: str) -> List[str]:
url = clean_vk_url(url)
user_opts_raw = shlex.split(options) if options.strip() else []
if not any(opt == "--newline" for opt in user_opts_raw):
user_opts_raw.append("--newline")
if not any(opt.startswith("--hls-prefer-ffmpeg") for opt in user_opts_raw):
user_opts_raw.append("--hls-prefer-ffmpeg")
if not any(opt.startswith("--retries") for opt in user_opts_raw):
user_opts_raw.extend(["--retries", "20"])
if not any(opt.startswith("--fragment-retries") for opt in user_opts_raw):
user_opts_raw.extend(["--fragment-retries", "50"])
if not any(opt.startswith("--sleep-interval") for opt in user_opts_raw):
user_opts_raw.extend(["--sleep-interval", "10"])
if not any(opt.startswith("--max-sleep-interval") for opt in user_opts_raw):
user_opts_raw.extend(["--max-sleep-interval", "30"])
if not any(opt.startswith("--no-playlist") for opt in user_opts_raw):
user_opts_raw.append("--no-playlist")
if not any(opt.startswith("--user-agent") for opt in user_opts_raw):
user_opts_raw.extend([
"--user-agent",
"Mozilla/5.0 (Windows NT 10.0; Win64; x64) AppleWebKit/537.36 "
"(KHTML, like Gecko) Chrome/115.0 Safari/537.36"
])
if is_extract_audio(options):
user_opts = []
skip_next = False
for opt in user_opts_raw:
if skip_next:
skip_next = False
continue
if opt in ("-f", "--format"):
skip_next = True
continue
user_opts.append(opt)
audio_opts = [
"--extract-audio",
"--audio-format", "m4a",
"--audio-quality", "320",
"--postprocessor-args", "-strict -2"
]
for o in audio_opts:
if not any(uopt == o or uopt.startswith(o + "=") for uopt in user_opts):
user_opts.append(o)
cmd = [YT_DLP_PATH, "-o", output_template] + user_opts + [url]
else:
user_opts = []
skip_next = False
for opt in user_opts_raw:
if skip_next:
skip_next = False
continue
if opt in ("-o", "--output"):
skip_next = True
continue
user_opts.append(opt)
cmd = [YT_DLP_PATH, "-o", output_template] + DEFAULT_OPTIONS + user_opts + [url]
return cmd
async def stream_yt_dlp(cmd, expected_filename):
import json
def sanitize_and_lower(s: str) -> str:
s = s.replace('\uff5c', '_')
s = re.sub(r'[<>:"/\\|?*]', '_', s)
s = s.lower()
return s
process = None
try:
process = await asyncio.create_subprocess_exec(
*cmd,
stdout=asyncio.subprocess.PIPE,
stderr=asyncio.subprocess.STDOUT,
)
last_percent = 0.0
while True:
try:
line_bytes = await asyncio.wait_for(process.stdout.readline(), timeout=900)
except asyncio.TimeoutError:
yield "\n\u274c Ошибка: время ожидания загрузки истекло.\n"
break
if not line_bytes:
break
line_raw = line_bytes.decode("utf-8", errors="ignore").rstrip('\n')
if "\r" in line_raw:
parts = line_raw.split('\r')
line = parts[-1].strip()
else:
line = line_raw.strip()
if (
line.startswith("[https @") or line.startswith("[http @") or line.startswith("[dashsegments]")
or line.startswith("[hls @") or line.startswith("[h264 @") or line.startswith("[aac @")
or line.startswith("[mp4 @") or line.startswith("[mov,mp4,m4a,3gp,3g2,mj2 @")
or line.startswith("[mpegts @") or line.startswith("[segment @")
or line.startswith("[data @") or line.startswith("[meta @")
):
continue
yield line + "\n"
m = re.search(r"(\d{1,3}(?:\.\d+)?)%", line)
if m:
try:
percent = float(m.group(1))
if percent >= last_percent:
last_percent = percent
yield json.dumps({"progress": percent}) + "\n"
except Exception:
pass
await process.wait()
for f in os.listdir(DOWNLOADS_DIR):
if '\uff5c' in f:
try:
os.rename(os.path.join(DOWNLOADS_DIR, f), os.path.join(DOWNLOADS_DIR, f.replace('\uff5c', '_')))
except: pass
base_expected_s = sanitize_and_lower(os.path.splitext(expected_filename)[0])
best_match = None
best_ratio = 0.0
for f in os.listdir(DOWNLOADS_DIR):
full_path = os.path.join(DOWNLOADS_DIR, f)
if os.path.isfile(full_path):
ratio = difflib.SequenceMatcher(None, base_expected_s, sanitize_and_lower(os.path.splitext(f)[0])).ratio()
if ratio > best_ratio and ratio > 0.5:
best_ratio = ratio
best_match = full_path
file_path = best_match
if not file_path:
yield json.dumps({"error": "Не удалось найти скачанный файл."}) + "\n"
return
base, ext = os.path.splitext(file_path)
for ext_to_del in [".part", ".ytdl", ".ytdl-temp"]:
tmp_path = base + ext_to_del
if os.path.exists(tmp_path):
try: os.unlink(tmp_path)
except: pass
sanitized_name = sanitize_filename(os.path.basename(file_path))
sanitized_path = os.path.join(DOWNLOADS_DIR, sanitized_name)
if file_path != sanitized_path:
try:
if os.path.exists(sanitized_path): os.remove(sanitized_path)
os.rename(file_path, sanitized_path)
file_path = sanitized_path
except: pass
download_url = "/download_file?file_name=" + quote(sanitized_name) + "&orig_name=" + quote(os.path.basename(file_path))
yield json.dumps({
"status": "ready",
"download_url": download_url,
"original_name": os.path.basename(file_path)
}) + "\n"
except Exception as ex:
yield "\n\u274c Ошибка сервера: " + str(ex) + "\n"
finally:
if process and process.returncode is None:
try: process.kill()
except: pass
await process.wait()
@app.get("/", response_class=HTMLResponse)
async def root(request: Request):
return templates.TemplateResponse("index.html", {"request": request})
@app.post("/download_stream")
async def download_stream(
url: str = Form(...),
options: str = Form(""),
cookies: Optional[UploadFile] = File(None)
):
temp_cookie_file = None
url = clean_vk_url(url)
try:
filename = await get_target_filename(url, options)
if not filename:
raise HTTPException(status_code=400, detail="Не удалось определить имя файла")
cmd = build_cmd(url, options, OUTPUT_TEMPLATE)
if cookies:
temp = tempfile.NamedTemporaryFile(delete=False, mode='wb')
temp.write(await cookies.read())
temp.close()
temp_cookie_file = temp.name
cmd += ["--cookies", temp_cookie_file]
return StreamingResponse(stream_yt_dlp(cmd, filename), media_type="text/plain")
except Exception as e:
raise HTTPException(status_code=500, detail=str(e))
finally:
if temp_cookie_file and os.path.exists(temp_cookie_file):
try: os.unlink(temp_cookie_file)
except: pass
@app.post("/clear_history_files")
async def clear_history_files():
try:
for filename in os.listdir(DOWNLOADS_DIR):
file_path = os.path.join(DOWNLOADS_DIR, filename)
if os.path.isfile(file_path):
os.remove(file_path)
return {"status": "ok"}
except Exception as e:
raise HTTPException(status_code=500, detail=str(e))
@app.get("/download_file")
async def download_file(file_name: str, orig_name: Optional[str] = Query(None)):
safe_path = os.path.abspath(os.path.join(DOWNLOADS_DIR, file_name))
if not safe_path.startswith(DOWNLOADS_DIR) or not os.path.isfile(safe_path):
raise HTTPException(status_code=404)
return FileResponse(path=safe_path, filename=orig_name or file_name)
@app.get("/formats", response_class=PlainTextResponse)
async def get_formats(url: str = Query(...)):
url = clean_vk_url(url)
cmd = [YT_DLP_PATH, "-F", url]
process = await asyncio.create_subprocess_exec(
*cmd,
stdout=asyncio.subprocess.PIPE,
stderr=asyncio.subprocess.PIPE,
)
stdout, stderr = await process.communicate()
return stdout.decode() if process.returncode == 0 else stderr.decode()
if __name__ == "__main__":
import uvicorn
uvicorn.run(app, host="0.0.0.0", port=8000)
index.html:
<!DOCTYPE html>
<html lang="ru">
<head>
<meta charset="UTF-8" />
<meta name="viewport" content="width=device-width, initial-scale=1" />
<title>yt-dlp Web Downloader</title>
<style>
body {
background-color: #2c2f33;
color: #eee;
font-family: Verdana, Geneva, Tahoma, sans-serif;
margin: 0; padding: 0;
min-height: 100vh;
display: flex;
justify-content: center;
align-items: flex-start;
padding-top: 40px;
}
#main-container {
background-color: #1e2124;
border-radius: 10px;
padding: 20px 30px;
max-width: 640px;
width: 100%;
box-shadow: 0 0 15px rgba(0,0,0,0.7);
}
label {
font-size: 14px;
color: #8eb9ff;
display: block;
margin-bottom: 6px;
}
textarea, input[type="text"], input[type="file"] {
width: 100%;
padding: 8px 12px;
margin-bottom: 15px;
border-radius: 5px;
border: none;
font-size: 14px;
background-color: #3a3d42;
color: #eee;
box-sizing: border-box;
resize: vertical;
}
button {
width: 100%;
padding: 12px 0;
border-radius: 6px;
border: none;
background-color: #2979ff;
color: white;
font-weight: 600;
font-size: 16px;
cursor: pointer;
margin-top: 5px;
transition: background-color 0.3s ease;
}
button:hover:not(:disabled) {
background-color: #1669ff;
}
button:disabled {
background-color: #555a66;
cursor: not-allowed;
}
#button-group {
display: flex;
gap: 10px;
margin-top: 5px;
}
#button-group button {
width: unset;
flex: 1;
}
#progress-container {
background-color: #3a3d42;
border-radius: 6px;
height: 24px;
width: 100%;
position: relative;
overflow: hidden;
margin-top: 10px;
box-sizing: border-box;
}
#progress-bar {
background-color: #4da6ff;
height: 100%;
width: 0;
transition: width 0.25s ease-out;
box-shadow: 0 0 8px 2px #4da6ff;
border-radius: 6px 0 0 6px;
}
#progress-text {
position: absolute;
width: 100%;
top: 0;
left: 0;
text-align: center;
color: #eee;
font-weight: 700;
line-height: 24px;
user-select: none;
pointer-events: none;
font-size: 14px;
}
#terminal {
background-color: #0b0d12;
color: #0f0;
font-family: monospace;
font-size: 13px;
white-space: pre-wrap;
overflow-y: auto;
height: 220px;
padding: 12px;
border-radius: 8px;
margin-top: 20px;
line-height: 1.3;
user-select: text;
}
#history {
margin-top: 30px;
max-height: 140px;
overflow-x: auto;
overflow-y: hidden;
background: #22272d;
border-radius: 8px;
padding: 10px 15px 10px 15px;
white-space: nowrap;
position: relative;
scrollbar-width: thin;
scrollbar-color: #4da6ff transparent;
}
#historyList {
list-style: none;
padding: 0;
margin: 0;
display: inline-flex;
gap: 16px;
align-items: center;
}
#historyList li {
display: inline-flex;
flex-direction: column;
align-items: center;
max-width: 200px;
white-space: normal;
word-break: break-word;
}
#historyList a {
color: #4da6ff;
text-decoration: none;
font-size: 13px;
text-align: center;
display: block;
max-width: 100%;
white-space: nowrap;
overflow: hidden;
text-overflow: ellipsis;
}
#historyList a:hover {
text-decoration: underline;
}
.copyBtn {
background: #555a66;
color: #eee;
border: none;
border-radius: 3px;
padding: 2px 6px;
margin-top: 6px;
cursor: pointer;
font-size: 11px;
width: 100%;
max-width: 100%;
white-space: nowrap;
overflow: hidden;
text-overflow: ellipsis;
transition: background-color 0.2s ease;
user-select: none;
}
.copyBtn:hover {
background-color: #2979ff;
}
#clearHistoryBtn {
position: absolute;
top: 8px;
right: 15px;
background: #d35400;
width: auto;
padding: 6px 14px;
border-radius: 6px;
border: none;
color: white;
font-weight: 600;
font-size: 14px;
cursor: pointer;
transition: background-color 0.3s ease;
}
#clearHistoryBtn:hover {
background-color: #b04100;
}
.options-help {
color: #aaa;
font-size: 13px;
margin-top: -10px;
margin-bottom: 15px;
line-height: 1.4;
}
.options-help code {
background-color: #2e3137;
padding: 2px 4px;
border-radius: 3px;
font-size: 13px;
}
nav#useful-links ul {
list-style: none;
padding: 0;
margin: 30px auto 0;
display: flex;
flex-wrap: wrap;
justify-content: center;
gap: 15px;
font-size: 14px;
}
nav#useful-links li a {
color: #4da6ff;
text-decoration: none;
padding: 10px 14px;
border-radius: 6px;
transition: background-color 0.3s ease;
display: inline-block;
}
nav#useful-links li a:hover,
nav#useful-links li a:focus {
background-color: #1669ff;
color: white;
}
@media (max-width: 480px) {
nav#useful-links ul {
flex-direction: column;
gap: 10px;
}
nav#useful-links li a {
font-size: 16px;
padding: 10px 14px;
}
}
@media (max-width: 480px) {
#clearHistoryBtn {
padding: 6px 10px;
font-size: 12px;
border-radius: 4px;
top: 12px;
right: 12px;
}
}
</style>
</head>
<body>
<div id="main-container" role="main" aria-label="Панель загрузки видео">
<h2>Загрузить видео по ссылке с опциями</h2>
<form id="downloadForm" onsubmit="event.preventDefault(); startDownload();">
<label for="url">Вставьте ссылку сюда:</label>
<textarea id="url" name="url" placeholder="https://www.youtube.com/watch?v=..." autocomplete="off" spellcheck="false" aria-required="true"></textarea>
<label for="cookies">Файл cookies (если нужен):</label>
<input type="file" id="cookies" name="cookies" aria-describedby="cookiesHelp" />
<label for="options">Дополнительные опции yt-dlp (по желанию):</label>
<input type="text" id="options" name="options" placeholder="--extract-audio --audio-format mp3 --audio-quality 320" autocomplete="off" spellcheck="false" />
<div class="options-help" id="cookiesHelp">
Примеры использования опций yt-dlp:<br/>
<code>--format</code> — выбрать качество или формат (best, mp4, mp3 и др.)<br/>
<code>--merge-output-format mp4</code> — объединить в MP4<br/>
<code>--write-sub --sub-lang ru,en</code> — скачать субтитры на русском и английском<br/>
<code>--no-playlist</code> — скачать только одно видео (без плейлистов)<br/>
<code>--ignore-errors</code> — пропускать ошибки, продолжать загрузку<br/>
<code>--extract-audio</code> — извлечь только аудио<br/>
<code>--audio-format</code> — конвертировать аудио (mp3, wav, aac и др.)<br/>
<code>--hls-use-mpegts</code> — улучшить скачивание прямых эфиров (HLS)<br/>
</div>
<div id="button-group" role="group" aria-label="Кнопки управления загрузкой">
<button type="submit" id="startBtn">Начать загрузку</button>
<button type="button" id="cancelBtn" disabled>Отмена</button>
<button type="button" id="downloadBtn" disabled>Загрузить на ПК</button>
<button type="button" id="showFormatsBtn">Показать форматы</button>
</div>
<div id="progress-container" role="progressbar" aria-valuemin="0" aria-valuemax="100" aria-valuenow="0" aria-live="polite" aria-label="Прогресс загрузки">
<div id="progress-bar"></div>
<div id="progress-text">0%</div>
</div>
</form>
<textarea id="formatsOutput" rows="15" readonly spellcheck="false" style="background-color:#2e3137; color:#ccc; margin-top:15px;"></textarea>
<div id="terminal" aria-live="polite" aria-atomic="true" role="log"></div>
<div id="history" role="region" aria-label="История загрузок">
<h3>История загрузок</h3>
<button type="button" id="clearHistoryBtn">Очистить историю</button>
<ul id="historyList"></ul>
</div>
<nav id="useful-links" aria-label="Полезные ссылки">
<ul>
<li><a href="https://github.com/yt-dlp/yt-dlp/releases" target="_blank" rel="noopener noreferrer">yt-dlp</a></li>
<li><a href="https://github.com/denoland/deno/" target="_blank" rel="noopener noreferrer">Deno</a></li>
<li><a href="https://www.johnvansickle.com/ffmpeg/" target="_blank" rel="noopener noreferrer">FFmpeg Static Builds</a></li>
<li><a href="https://github.com/yt-dlp/yt-dlp/blob/master/supportedsites.md" target="_blank" rel="noopener noreferrer">Список поддерживаемых сайтов</a></li>
<li><a href="https://github.com/GyanD/codexffmpeg/releases" target="_blank" rel="noopener noreferrer">FFmpegGyanD</a></li>
<li><a href="https://github.com/yt-dlp/FFmpeg-Builds/releases" target="_blank" rel="noopener noreferrer">FFmpeg-Builds</a></li>
</nav>
</div>
<script>
const term = document.getElementById('terminal');
const downloadBtn = document.getElementById("downloadBtn");
const startBtn = document.getElementById("startBtn");
const cancelBtn = document.getElementById("cancelBtn");
const showFormatsBtn = document.getElementById("showFormatsBtn");
const formatsOutput = document.getElementById("formatsOutput");
const progressBar = document.getElementById("progress-bar");
const progressText = document.getElementById("progress-text");
const historyList = document.getElementById("historyList");
const clearHistoryBtn = document.getElementById("clearHistoryBtn");
let downloadUrl = "";
let controller = null;
let isDownloadCompleted = false; // ФЛАГ ДЛЯ ФОРСИРОВАНИЯ 100%
const apiBase = "";
function appendTerminal(line) {
if (!line) return;
line = line.trimEnd();
if (line.includes('\r')) {
const parts = line.split('\r');
const lastPart = parts[parts.length - 1].trim();
let lines = term.textContent.split('\n');
if (lines.length > 0) lines.pop();
lines.push(lastPart);
term.textContent = lines.join('\n');
} else {
term.textContent += line + '\n';
}
const maxLines = 500;
let allLines = term.textContent.split('\n');
if (allLines.length > maxLines) {
term.textContent = allLines.slice(allLines.length - maxLines).join('\n');
}
term.scrollTop = term.scrollHeight;
}
function showProgress(percent) {
if (isDownloadCompleted && percent < 100) return; // БЛОКИРУЕМ ОТКАТ
const p = Math.min(100, Math.max(0, percent));
progressBar.style.width = p + '%';
progressText.textContent = `${Math.round(p)}%`;
progressBar.parentElement.setAttribute('aria-valuenow', p);
}
function resetProgress() {
isDownloadCompleted = false;
showProgress(0);
}
function addToHistory(name, url) {
if (!name || !url) return;
let history = JSON.parse(localStorage.getItem('downloadHistory') || '[]');
if (history.find(item => item.url === url)) return;
history.unshift({name: name, url: url});
if (history.length > 50) history.pop();
localStorage.setItem('downloadHistory', JSON.stringify(history));
loadHistory();
}
function loadHistory() {
let history = JSON.parse(localStorage.getItem('downloadHistory') || '[]');
const seen = new Set();
historyList.innerHTML = '';
history.forEach(item => {
if (seen.has(item.url)) return;
seen.add(item.url);
const li = document.createElement('li');
const link = document.createElement('a');
const finalUrl = new URL(item.url, window.location.origin + window.location.pathname).href;
link.href = finalUrl;
link.target = '_blank';
link.rel = "noopener noreferrer";
link.textContent = item.name.split(/[\\/]/).pop();
const copyBtn = document.createElement('button');
copyBtn.textContent = 'Копировать';
copyBtn.className = 'copyBtn';
copyBtn.title = 'Скопировать ссылку в буфер';
copyBtn.onclick = () => {
navigator.clipboard.writeText(finalUrl).then(() => {
alert('Ссылка скопирована в буфер обмена');
}, () => {
alert('Ошибка копирования ссылки');
});
};
li.appendChild(link);
li.appendChild(copyBtn);
historyList.appendChild(li);
});
}
clearHistoryBtn.onclick = async () => {
if (confirm("Вы действительно хотите очистить историю загрузок и удалить файлы с сервера?")) {
try {
const resp = await fetch(apiBase + "clear_history_files", {
method: "POST"
});
if (!resp.ok) {
const text = await resp.text();
alert("Ошибка очистки файлов на сервере: " + text);
return;
}
localStorage.removeItem('downloadHistory');
loadHistory();
alert("История и файлы на сервере очищены.");
} catch (e) {
alert("Ошибка при очистке: " + e.message);
}
}
};
downloadBtn.onclick = () => {
if (downloadUrl) {
window.open(downloadUrl, "_blank");
} else {
alert("Сначала начните загрузку и дождитесь её окончания.");
}
};
cancelBtn.onclick = () => {
if (controller) {
controller.abort();
}
};
showFormatsBtn.onclick = async () => {
const urlInput = document.getElementById("url");
const url = urlInput.value.trim();
const formatsOutput = document.getElementById("formatsOutput");
formatsOutput.value = "";
if (!url) {
alert("Пожалуйста, введите ссылку для отображения форматов");
urlInput.focus();
return;
}
formatsOutput.value = "Загрузка форматов...";
try {
const response = await fetch(apiBase + "formats?url=" + encodeURIComponent(url));
if (!response.ok) {
formatsOutput.value = "Ошибка при получении форматов: " + response.statusText;
return;
}
const text = await response.text();
formatsOutput.value = text || "Нет доступных форматов для данного URL.";
} catch (e) {
formatsOutput.value = "Ошибка при запросе форматов: " + e.message;
}
};
async function startDownload() {
isDownloadCompleted = false;
const url = document.getElementById("url").value.trim();
const options = document.getElementById("options").value.trim();
const cookiesFile = document.getElementById("cookies").files[0];
downloadUrl = "";
startBtn.disabled = true;
cancelBtn.disabled = false;
downloadBtn.disabled = true;
term.textContent = "";
resetProgress();
if (!url) {
alert("Пожалуйста, введите ссылку на видео!");
startBtn.disabled = false;
cancelBtn.disabled = true;
return;
}
const formData = new FormData();
formData.append("url", url);
formData.append("options", options);
if (cookiesFile) formData.append("cookies", cookiesFile);
controller = new AbortController();
try {
const response = await fetch(apiBase + "download_stream", {
method: "POST",
body: formData,
signal: controller.signal,
});
if (!response.ok) {
appendTerminal("\u274c Ошибка сервера при загрузке.");
alert("Ошибка сервера при загрузке.");
startBtn.disabled = false;
cancelBtn.disabled = true;
return;
}
const reader = response.body.getReader();
const decoder = new TextDecoder();
let buffer = "";
while (true) {
const { value, done } = await reader.read();
if (done) break;
buffer += decoder.decode(value, { stream: true });
const lines = buffer.split("\n");
buffer = lines.pop();
for (const lineRaw of lines) {
const line = lineRaw.trim();
if (!line) continue;
appendTerminal(line);
try {
const obj = JSON.parse(line);
if (obj.progress !== undefined) showProgress(obj.progress);
if (obj.status === "ready" && obj.download_url) {
isDownloadCompleted = true; // СТАВИМ ФЛАГ ТУТ
showProgress(100);
downloadUrl = obj.download_url;
downloadBtn.disabled = false;
alert("Загрузка завершена!");
addToHistory(obj.original_name || "видео", downloadUrl);
return;
}
} catch (e) {
// Если это не JSON, ищем процент в тексте
const progressMatch = line.match(/(\d{1,3}(?:\.\d+)?)%/);
if (progressMatch) showProgress(parseFloat(progressMatch[1]));
}
}
}
} catch (e) {
if (e.name === "AbortError") appendTerminal("\u274c Отмена.");
else alert("Ошибка запроса: " + e.message);
} finally {
startBtn.disabled = false;
cancelBtn.disabled = true;
}
}
loadHistory();
</script>
</body>
</html>